Skip to content

perf(codegen): reduce accumulators earn the packed fast clone's numeric proof — s += arr[i] at node parity (was 5.3×) - #9060

Merged
proggeramlug merged 3 commits into
PerryTS:mainfrom
proggeramlug:perf/packed-loop-accumulator-v2
Aug 29, 2026
Merged

perf(codegen): reduce accumulators earn the packed fast clone's numeric proof — s += arr[i] at node parity (was 5.3×)#9060
proggeramlug merged 3 commits into
PerryTS:mainfrom
proggeramlug:perf/packed-loop-accumulator-v2

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

What

Reduce loops — for (let i = 0; i < arr.length; i++) s += arr[i] — now run
their stable-packed fast clone at full speed. Two independent fixes, each
useless without the other:

  1. The accumulator earns the clone's numeric proof. The loop guard proves
    the element is raw f64, but the accumulator's own writes are circular for
    every whole-function numeric fact, so s + arr[i] kept one unproven operand
    and lowered through js_dynamic_string_or_number_add on every element (25%
    of the isolated loop). This ports the element-shape clone's
    numeric_accumulator design: collect_numeric_accumulators
    (stmt/stable_packed_accumulator.rs) admits plain, uncaptured, unboxed
    locals whose every body write is numeric with all leaves provable
    in-loop (fail-closed fixpoint; nested closures not descended — their
    captures are boxed and already excluded). The fast preheader tag-tests each
    admitted accumulator (the induction base case) and takes the slow clone on
    any non-Number; the fact rides StablePackedLoopFact::numeric_accumulators,
    scoped to the fast-clone lowering exactly as the element facts are, and
    is_numeric_expr's LocalGet arm consumes it like the element-shape twin.

  2. js_shadow_slot_set is exempted from the call-free clone scans. With
    the add fixed, the loop was still 5× slower, and nothing pointed at why:
    the guard admitted (lldb shows return 1), the fast body IR was perfect
    (19 instructions, no calls), timing showed slow. The cause sat in the
    admission block's terminator: the accumulator's per-statement shadow
    CLEAR — emitted precisely because the stored value is a proven
    non-pointer — failed fast_clone_call_free, and the admission arm then
    emits an unconditional branch to the slow preheader while still calling
    (and discarding) the guard. js_shadow_slot_set is a bounds-checked TLS
    store (gc/roots/shadow_stack.rs): it cannot allocate, collect, or revoke
    a layout, which is exactly what the two call-free scans exist to exclude.
    The exemption arms both this tier and the element-shape tier; the
    diagnostic that found it is preserved at
    secret-tests/tools/packed-scan-debug.patch.

Numbers

Isolated (Mac, 1000-element number[] behind a closure/module-global receiver,
node 26.5.1 = 1009 ns/1k on the same machine):

shape before after
s += arr[i], i < arr.length 5341 ns/1k 993–1040 ns/1k = node parity
same, no-array control (s += i) 990 990 (unchanged — proves the loop scaffolding was never the cost)

wolf-ecs (11 pairs, both windows): ±0.08%, neutral — its hot loops don't have
this shape. Two admission residuals are documented in the matcher for
follow-up: compare-only consumption (if (arr[i] < 0) — the leading-statement
matcher does not look into If conditions) and literal-bound loops
(i < 1000), both still ~4.3 µs/1k.

Semantics

Differential vs node, identical output: string accumulators (concat preserved —
the preheader tag test routes them to the slow clone), arrays with non-number
elements (the guard declines numeric mode), NaN and -0 accumulators, in-loop
reassignment to a string (admission declines the accumulator), multiple
accumulators with -/*/Math.* chains, update-form counters (c++), and
fact scoping across back-to-back loops. PERRY_PACKED_LOOP_NUMERIC_ACCUMULATOR=0
restores the old lowering, output-identical; the scan exemption is
unconditional because it is a factual classification of the callee, not a
policy.

Testing

  • RUSTFLAGS=-D warnings cargo check --workspace --all-targets (host excludes) — clean.
  • perry-codegen 1823 / perry-runtime --lib 2807 — green (re-run after the
    file-size split of the matcher into stable_packed_accumulator.rs).
  • Integration: issue_8655_array_subclass_indexing,
    issue_8690_loop_versioned_arraylike, issue_8773_closure_capture_packed_loops,
    issue_8897_field_push_writeback — 12/12.
  • Lint: census, address-class, gc-store-site, file-size, raw-handle debt.

Summary by CodeRabbit

  • Performance Improvements

    • Improved packed-loop reductions by using faster numeric accumulation when values are confirmed to be numbers.
    • Reduced dynamic addition overhead, improving performance for eligible numeric loops.
    • Preserved fast-path execution when accumulator shadow slots are cleared.
  • Build & Reliability

    • Compiler setting changes now correctly invalidate affected build-cache entries.
    • Improved safety handling for shadow-slot updates during optimized execution.

…ric proof

`for (let i = 0; i < arr.length; i++) s += arr[i]` — the most common reduce
shape in JavaScript — ran 5.3x slower than node, and BOTH halves of the reason
were invisible to profiling alone:

1. Inside the fast clone, `s += arr[i]` still lowered `+` through
   `js_dynamic_string_or_number_add` (25% of the isolated loop): the loop
   guard proves the ELEMENT is raw f64, but the accumulator's own writes are
   circular for every whole-function numeric fact, so the add had one unproven
   operand. The element-shape clone already solved this with its
   `numeric_accumulator` (preheader tag test = the induction base case; every
   in-clone write numeric-preserving = the step). This ports that design:
   `collect_numeric_accumulators` admits plain, uncaptured, unboxed locals
   whose every body write is numeric with all leaves provable in-loop
   (fail-closed fixpoint; nested closures not descended — their captures are
   boxed and excluded anyway), the fast preheader tag-tests each one and takes
   the slow clone on any non-Number, and the fact rides
   `StablePackedLoopFact::numeric_accumulators`, scoped to the fast-clone
   lowering exactly as the element facts are.

2. With the add fixed, the clone was STILL dead: the accumulator's
   per-statement shadow CLEAR — `js_shadow_slot_set(slot, 0)`, emitted
   precisely BECAUSE the stored value is a proven non-pointer — failed
   `fast_clone_call_free`, and the admission arm then emits an UNCONDITIONAL
   branch to the slow preheader while still calling (and discarding) the
   guard. Timing shows slow, lldb on the guard shows "admitted", the IR shows
   a perfect fast body: nothing points at the terminator. `js_shadow_slot_set`
   is a bounds-checked TLS store (`gc/roots/shadow_stack.rs`) that cannot
   allocate, collect, or revoke a layout — which is precisely what the two
   call-free clone scans exist to exclude — so `is_gc_unsafe_call` now exempts
   it, for both this tier and the element-shape tier.

The accumulator machinery lives in `stmt/stable_packed_accumulator.rs` (the
2,000-line file gate). `PERRY_PACKED_LOOP_NUMERIC_ACCUMULATOR=0` restores the
old lowering; the scan exemption is unconditional (it is a factual
classification, not a policy).

Isolated reduce loop (Mac, 1k elements): 5341 -> 993-1040 ns = node parity
(node 1009). wolf-ecs: +-0.08%, neutral. Differential vs node identical:
string accumulators (concat preserved via the slow clone), mixed-element
arrays (guard declines numeric mode), NaN/-0, in-loop reassignment to string
(admission declines), multiple accumulators, Math chains, update-form
counters. Kill switch output-identical.

Claude-Session: https://claude.ai/code/session_019WVcWKmYsUBnnFB7nBgbBJ
@coderabbitai

coderabbitai Bot commented Aug 29, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d8dbf071-88d3-4822-879b-0d76aa2f4e58

📥 Commits

Reviewing files that changed from the base of the PR and between e3164ee and 5d19b31.

📒 Files selected for processing (2)
  • changelog.d/9060-packed-loop-numeric-accumulator.md
  • crates/perry/src/commands/compile/build_cache.rs

📝 Walkthrough

Walkthrough

The compiler now identifies numeric reduce-loop accumulators, validates them in the packed-loop preheader, and uses native numeric addition in the fast clone. The optimization has an environment gate, build-cache tracking, GC-safe shadow-slot handling, and changelog documentation.

Changes

Packed-loop numeric accumulator optimization

Layer / File(s) Summary
Accumulator proof and collection
crates/perry-codegen/src/stmt/stable_packed_accumulator.rs, crates/perry-codegen/src/stmt/mod.rs
The compiler gates numeric accumulator analysis, tracks local writes, and retains candidates whose writes are numeric-preserving.
Fast-clone numeric guard
crates/perry-codegen/src/expr/mod.rs, crates/perry-codegen/src/stmt/stable_packed_loop.rs, crates/perry-codegen/src/type_analysis/numeric.rs
The packed-loop preheader checks accumulator values with emit_js_value_is_number. Proven accumulator locals enable native numeric addition in the fast clone.
Call classification and build metadata
crates/perry-codegen/src/inst.rs, crates/perry/src/commands/compile/build_cache.rs, changelog.d/9060-packed-loop-numeric-accumulator.md
js_shadow_slot_set is classified as GC-safe. The feature setting becomes a build-cache input, and the optimization is documented.

Estimated code review effort: 4 (Complex) | ~45 minutes

Merge Risk: ⚪ Minimal · up to e3164

The optimization adds guarded numeric execution for qualifying loops while preserving fallback behavior for unsupported values and cases. No actionable merge-blocking risk remains after normal checks and review.

Sequence Diagram(s)

sequenceDiagram
  participant PackedLoopLower
  participant AccumulatorAnalysis
  participant FastClone
  participant SlowClone
  PackedLoopLower->>AccumulatorAnalysis: Collect numeric accumulator locals
  AccumulatorAnalysis-->>PackedLoopLower: Return proven accumulator IDs
  PackedLoopLower->>FastClone: Test accumulator values as Numbers
  FastClone-->>PackedLoopLower: Enter when all tests pass
  PackedLoopLower->>SlowClone: Branch when any test fails
  FastClone->>FastClone: Lower numeric addition to native fadd
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 54.55% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 11 functions across 6 files. (1 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the code-generation performance change: numeric proofs for reduce accumulators and Node-parity performance. It is specific and related to the main change.
Description check ✅ Passed The description provides a detailed summary, concrete implementation changes, benchmark results, semantic coverage, and test results. It uses alternative headings and omits the template's Related issu…
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Description check

Explanation

The description provides a detailed summary, concrete implementation changes, benchmark results, semantic coverage, and test results. It uses alternative headings and omits the template's Related issue and Checklist sections, but it is otherwise substantially complete.

Full details: Docstring Coverage

Explanation

Docstring coverage is 54.55% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 11 functions across 6 files. (1 skipped: 1 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Ralph Küpper added 2 commits August 29, 2026 17:38
…his PR's knob

Merging current main into this branch REVERTED PerryTS#9044: commit e3164ee removed
`PERRY_BOX_CAPTURE_ENTRY_CELLS` and `PERRY_GUARDED_PREINLINE_MAX_IR_BYTES` from
BUILD_CACHE_ENV_VARS along with PerryTS#9044's changelog fragment. The branch predates
that fix, so the commit was built over a stale tree and carries the removal as
an intentional-looking deletion -- which a merge then honours.

That alone would have re-reddened main: the assertion lives in a bin-crate unit
test, so its failure stops the whole `perry` test binary compiling and every
open PR's cargo-test job goes red.

This PR also adds a third codegen knob, PERRY_PACKED_LOOP_NUMERIC_ACCUMULATOR,
without registering it. It is a cache INPUT, not an exclusion: with it on,
`s += arr[i]` lowers to an inline fadd instead of
`js_dynamic_string_or_number_add`, so the two settings emit different code and
must never share a cached object.

All three registered; fragment renumbered 0000 -> 9060.
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Merged, after fixing a silent revert that would have re-reddened main.

The revert

Commit e3164ee7a8 removes PERRY_BOX_CAPTURE_ENTRY_CELLS and PERRY_GUARDED_PREINLINE_MAX_IR_BYTES from BUILD_CACHE_ENV_VARS, plus #9044's changelog fragment. The branch predates #9044, so the commit was built over a stale tree and carries the removal as an intentional-looking deletion — which a merge then honours rather than discards. Merging main in and re-checking is what surfaced it; the PR's own file list shows it as -14/-8 if you know to look.

This is the same shape as the war story in #9021 (a git reset --soft over a stale tree silently reverting #9013). Worth a habit: after any rebase/squash of a long-lived branch, git diff origin/main --stat and look for deletions you did not intend.

It would not have been a quiet regression. The assertion is a bin-crate unit test, so its failure stops the whole perry test binary from compiling and every open PR's cargo-test job goes red — which is exactly how #9044 was found.

The third knob

This PR also adds PERRY_PACKED_LOOP_NUMERIC_ACCUMULATOR without registering it, so post-merge there would have been three unregistered vars, not two. Registered as an input, not an exclusion: with it on, s += arr[i] lowers to an inline fadd instead of js_dynamic_string_or_number_add, so the two settings emit different code and must never share a cached object.

Fragment renumbered 0000-9060-.

The optimization itself

The interesting risk is the accumulator earning a numeric proof it does not deserve, so I probed the ways an accumulator leaves the numeric domain: a string accumulator (s = ""), an accumulator that goes non-numeric mid-loop, a string element mid-array, NaN/Infinity/-0, two accumulators in one loop where only one is numeric, an accumulator captured by a closure (which must be excluded as boxed), and *=/-= reductions. Byte-identical to node in both knob states, so the preheader tag-test genuinely side-exits rather than the fast clone swallowing a non-Number.

The fail-closed fixpoint plus excluding captured/boxed locals is the right shape — it is the same admission discipline as #9018's number-by-construction and #9026's capture cells.

Validation: perry-codegen 1345/0, perry-runtime --lib 2807/0, perry --bins 1066/0, fmt --check, run_lint_gates.sh all 60 gates passed; 2 CI-only skipped.

@proggeramlug
proggeramlug merged commit 5792671 into PerryTS:main Aug 29, 2026
22 of 29 checks passed
proggeramlug pushed a commit to proggeramlug/perry that referenced this pull request Aug 29, 2026
…erryTS#9062

The branch's commit was authored over a tree predating PerryTS#9060/PerryTS#9062 and
committed onto a newer base, so it carried deletions of files its own parent
contains: PerryTS#9060's whole `stable_packed_accumulator.rs` (273 lines) and its
build-cache knob, plus PerryTS#9062's `for_multi_decl_tests.rs`, its
`issue_9052_for_lexical_declarators.rs` integration test, the `Module` field and
its stable-hash entry, and ~40 test-fixture updates. 477 deletions in total.

Replayed the six files that are genuinely this change onto current main
instead: closure.rs, function.rs, helpers.rs, hoisted_callback_calls.rs,
collectors/mod.rs and the fragment. Net diff vs main is now additions only,
with zero deletions.

Also fixes two gate failures of its own:

* PERRY_CALLEE_BINDING_RESOLUTION was unregistered, failing
  `codegen_env_vars_are_build_cache_inputs`. Registered as an INPUT: the two
  settings emit different call sequences, so a cached object from one must not
  serve the other.
* `local_binding_type_audit.py` wants the new `local_type_hint` read
  classified. Recorded as `runtime-validated`: the declared function type only
  narrows which bindings are ATTEMPTED, while
  `js_closure_resolve_arrow_direct_call(handle, arity)` validates identity and
  arity at runtime, so a wrong declared type yields a failed resolution and the
  ordinary dynamic call rather than a wrong callee.

Fragment renumbered 0000 -> 9071.
proggeramlug added a commit that referenced this pull request Aug 29, 2026
The branch's commit was authored over a tree predating #9060/#9062 and
committed onto a newer base, so it carried deletions of files its own parent
contains: #9060's whole `stable_packed_accumulator.rs` (273 lines) and its
build-cache knob, plus #9062's `for_multi_decl_tests.rs`, its
`issue_9052_for_lexical_declarators.rs` integration test, the `Module` field and
its stable-hash entry, and ~40 test-fixture updates. 477 deletions in total.

Replayed the six files that are genuinely this change onto current main
instead: closure.rs, function.rs, helpers.rs, hoisted_callback_calls.rs,
collectors/mod.rs and the fragment. Net diff vs main is now additions only,
with zero deletions.

Also fixes two gate failures of its own:

* PERRY_CALLEE_BINDING_RESOLUTION was unregistered, failing
  `codegen_env_vars_are_build_cache_inputs`. Registered as an INPUT: the two
  settings emit different call sequences, so a cached object from one must not
  serve the other.
* `local_binding_type_audit.py` wants the new `local_type_hint` read
  classified. Recorded as `runtime-validated`: the declared function type only
  narrows which bindings are ATTEMPTED, while
  `js_closure_resolve_arrow_direct_call(handle, arity)` validates identity and
  arity at runtime, so a wrong declared type yields a failed resolution and the
  ordinary dynamic call rather than a wrong callee.

Fragment renumbered 0000 -> 9071.

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
proggeramlug pushed a commit to proggeramlug/perry that referenced this pull request Aug 29, 2026
…umulator proofs, hazard relaxation

Follow-up to PerryTS#9060/PerryTS#9063/PerryTS#9070: the packed-loop admission residuals behind
the compare-only and reduce shapes.

1. If-conditions were invisible to the stable-packed matcher (PerryTS#9060's
   documented residual): stmt_flags / leading_read_requires_numeric
   matched only Let/Expr/Throw/Return, so `if (arr[i] < 0) count++`
   never admitted — and later reads inside If branches were invisible to
   the replay-safety check. Both now descend; `Compare` joins `Binary`
   as a numeric-consumption context (a wrong hint fails the
   require_numeric guard into the generic loop, never a wrong answer).

2. Numeric accumulators for the plain packed clones (versioned + range):
   `s += a[i]` inside a packed fast clone lowered its `+` through
   js_dynamic_string_or_number_add on EVERY iteration plus two root
   barriers — the by-construction collector runs before clone facts
   exist, so `s` had no proof. The packed fast preheaders now run
   PerryTS#9060's collect_numeric_accumulators with one Number tag test each (a
   non-Number accumulator takes the slow clone before anything ran), and
   the ids ride PackedF64LoopFact.numeric_accumulators, consulted by
   is_numeric_expr — the same mechanism and kill switch as the stable
   clone.

3. Guarded reads are numeric inside clones: has_numeric_index_fact and
   the boxed-fallback hazard predicate now recognize packed
   versioned/range facts and masked-window facts — the clone's read
   either produces a genuine raw double or side-exits BEFORE the value
   is consumed, so there is no boxed edge. This turns `if (a[i] < 0)`
   into a bare fcmp (was js_rel_lt per iteration) and feeds the
   accumulator walk.

4. Versioned-loop READ bodies take the store arm's relaxed eligibility:
   a call-free read body cannot invalidate what the entry guard
   re-proves, so the whole-function materialization hazard (tripped by
   the very `new Array(n).fill()` construction calls that build these
   buffers) no longer blocks versioning — locally-built arrays version
   at all. Same argument, word for word, as the existing store-arm
   comment; the two invalidation tests that pinned the read-side
   conservatism now pin the versioned-behind-guard contract their store
   twin already used.

A stable-tier literal-bound arm was built and WITHDRAWN: plain-array
literal bounds already version through the range loop, and the arm
re-claimed five-field object-write bodies that
nested_same_shape_object_writes deliberately keeps outside any clone.

Isolated (dev box; node 26.5 in parens):
count loop `if (a[i]<0) c++`      4.58 -> 1.60 ns/el (0.62)
literal reduce `i<8192, s+=a[i]`  4.32 -> 4.16       (1.01)
len-bound reduce (local array)     5.30 -> 4.14       (0.99)
The count loop's residual is the per-iteration length IC, which PerryTS#9070's
hoist removes at merge. The reduce rows are now call-free (census: fadd
plus the loop poll only) and latency-bound on the accumulator's GC-root
slot — true parity there needs unboxed accumulator slots in clones,
scoped as the follow-on.

Nine-probe differential vs node byte-identical, incl. a string
accumulator (tag test -> slow clone -> concat), a literal bound past the
array length (guard fail -> undefined += NaN), holey/mixed arrays, and
an accumulator reassigned to a string mid-loop through a branch.
perry-codegen suites 1823/0.
proggeramlug pushed a commit to proggeramlug/perry that referenced this pull request Aug 29, 2026
…umulator proofs, hazard relaxation

Follow-up to PerryTS#9060/PerryTS#9063/PerryTS#9070: the packed-loop admission residuals behind
the compare-only and reduce shapes.

1. If-conditions were invisible to the stable-packed matcher (PerryTS#9060's
   documented residual): stmt_flags / leading_read_requires_numeric
   matched only Let/Expr/Throw/Return, so `if (arr[i] < 0) count++`
   never admitted — and later reads inside If branches were invisible to
   the replay-safety check. Both now descend; `Compare` joins `Binary`
   as a numeric-consumption context (a wrong hint fails the
   require_numeric guard into the generic loop, never a wrong answer).

2. Numeric accumulators for the plain packed clones (versioned + range):
   `s += a[i]` inside a packed fast clone lowered its `+` through
   js_dynamic_string_or_number_add on EVERY iteration plus two root
   barriers — the by-construction collector runs before clone facts
   exist, so `s` had no proof. The packed fast preheaders now run
   PerryTS#9060's collect_numeric_accumulators with one Number tag test each (a
   non-Number accumulator takes the slow clone before anything ran), and
   the ids ride PackedF64LoopFact.numeric_accumulators, consulted by
   is_numeric_expr — the same mechanism and kill switch as the stable
   clone.

3. Guarded reads are numeric inside clones: has_numeric_index_fact and
   the boxed-fallback hazard predicate now recognize packed
   versioned/range facts and masked-window facts — the clone's read
   either produces a genuine raw double or side-exits BEFORE the value
   is consumed, so there is no boxed edge. This turns `if (a[i] < 0)`
   into a bare fcmp (was js_rel_lt per iteration) and feeds the
   accumulator walk.

4. Versioned-loop READ bodies take the store arm's relaxed eligibility:
   a call-free read body cannot invalidate what the entry guard
   re-proves, so the whole-function materialization hazard (tripped by
   the very `new Array(n).fill()` construction calls that build these
   buffers) no longer blocks versioning — locally-built arrays version
   at all. Same argument, word for word, as the existing store-arm
   comment; the two invalidation tests that pinned the read-side
   conservatism now pin the versioned-behind-guard contract their store
   twin already used.

A stable-tier literal-bound arm was built and WITHDRAWN: plain-array
literal bounds already version through the range loop, and the arm
re-claimed five-field object-write bodies that
nested_same_shape_object_writes deliberately keeps outside any clone.

Isolated (dev box; node 26.5 in parens):
count loop `if (a[i]<0) c++`      4.58 -> 1.60 ns/el (0.62)
literal reduce `i<8192, s+=a[i]`  4.32 -> 4.16       (1.01)
len-bound reduce (local array)     5.30 -> 4.14       (0.99)
The count loop's residual is the per-iteration length IC, which PerryTS#9070's
hoist removes at merge. The reduce rows are now call-free (census: fadd
plus the loop poll only) and latency-bound on the accumulator's GC-root
slot — true parity there needs unboxed accumulator slots in clones,
scoped as the follow-on.

Nine-probe differential vs node byte-identical, incl. a string
accumulator (tag test -> slow clone -> concat), a literal bound past the
array length (guard fail -> undefined += NaN), holey/mixed arrays, and
an accumulator reassigned to a string mid-loop through a branch.
perry-codegen suites 1823/0.
proggeramlug added a commit that referenced this pull request Aug 29, 2026
…umulator proofs, hazard relaxation (#9084)

Follow-up to #9060/#9063/#9070: the packed-loop admission residuals behind
the compare-only and reduce shapes.

1. If-conditions were invisible to the stable-packed matcher (#9060's
   documented residual): stmt_flags / leading_read_requires_numeric
   matched only Let/Expr/Throw/Return, so `if (arr[i] < 0) count++`
   never admitted — and later reads inside If branches were invisible to
   the replay-safety check. Both now descend; `Compare` joins `Binary`
   as a numeric-consumption context (a wrong hint fails the
   require_numeric guard into the generic loop, never a wrong answer).

2. Numeric accumulators for the plain packed clones (versioned + range):
   `s += a[i]` inside a packed fast clone lowered its `+` through
   js_dynamic_string_or_number_add on EVERY iteration plus two root
   barriers — the by-construction collector runs before clone facts
   exist, so `s` had no proof. The packed fast preheaders now run
   #9060's collect_numeric_accumulators with one Number tag test each (a
   non-Number accumulator takes the slow clone before anything ran), and
   the ids ride PackedF64LoopFact.numeric_accumulators, consulted by
   is_numeric_expr — the same mechanism and kill switch as the stable
   clone.

3. Guarded reads are numeric inside clones: has_numeric_index_fact and
   the boxed-fallback hazard predicate now recognize packed
   versioned/range facts and masked-window facts — the clone's read
   either produces a genuine raw double or side-exits BEFORE the value
   is consumed, so there is no boxed edge. This turns `if (a[i] < 0)`
   into a bare fcmp (was js_rel_lt per iteration) and feeds the
   accumulator walk.

4. Versioned-loop READ bodies take the store arm's relaxed eligibility:
   a call-free read body cannot invalidate what the entry guard
   re-proves, so the whole-function materialization hazard (tripped by
   the very `new Array(n).fill()` construction calls that build these
   buffers) no longer blocks versioning — locally-built arrays version
   at all. Same argument, word for word, as the existing store-arm
   comment; the two invalidation tests that pinned the read-side
   conservatism now pin the versioned-behind-guard contract their store
   twin already used.

A stable-tier literal-bound arm was built and WITHDRAWN: plain-array
literal bounds already version through the range loop, and the arm
re-claimed five-field object-write bodies that
nested_same_shape_object_writes deliberately keeps outside any clone.

Isolated (dev box; node 26.5 in parens):
count loop `if (a[i]<0) c++`      4.58 -> 1.60 ns/el (0.62)
literal reduce `i<8192, s+=a[i]`  4.32 -> 4.16       (1.01)
len-bound reduce (local array)     5.30 -> 4.14       (0.99)
The count loop's residual is the per-iteration length IC, which #9070's
hoist removes at merge. The reduce rows are now call-free (census: fadd
plus the loop poll only) and latency-bound on the accumulator's GC-root
slot — true parity there needs unboxed accumulator slots in clones,
scoped as the follow-on.

Nine-probe differential vs node byte-identical, incl. a string
accumulator (tag test -> slow clone -> concat), a literal bound past the
array length (guard fail -> undefined += NaN), holey/mixed arrays, and
an accumulator reassigned to a string mid-loop through a branch.
perry-codegen suites 1823/0.

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant